🏠

Chapter 25: The Backtracking Framework

Understanding Backtracking Through a Real Problem

Understanding Backtracking Through a Real Problem

Backtracking is a systematic way to explore all possible solutions to a problem by building candidates incrementally and abandoning them ("backtracking") as soon as we determine they cannot lead to a valid solution. It's recursion with a specific purpose: exploring a decision tree where each node represents a choice, and we need to find paths that satisfy certain constraints.

Let's ground this in a concrete problem that will serve as our reference throughout this chapter.

The Problem: Generating Valid Phone Number Mnemonics

Phone keypads map digits to letters (like old T9 texting): - 2 → ABC - 3 → DEF - 4 → GHI - 5 → JKL - 6 → MNO - 7 → PQRS - 8 → TUV - 9 → WXYZ

Given a phone number like "23", we want to generate all possible letter combinations: ["AD", "AE", "AF", "BD", "BE", "BF", "CD", "CE", "CF"].

This is our anchor example. We'll build a solution from scratch, watch it fail in instructive ways, and progressively refine it using backtracking principles.

# Our reference problem: phone number to letter combinations
# We'll start with a naive approach and evolve it

# First, let's define the digit-to-letter mapping
DIGIT_TO_LETTERS = {
    '2': 'ABC',
    '3': 'DEF',
    '4': 'GHI',
    '5': 'JKL',
    '6': 'MNO',
    '7': 'PQRS',
    '8': 'TUV',
    '9': 'WXYZ'
}

def phone_mnemonics_naive(digits):
    """
    Naive attempt: try to generate all combinations
    This version will help us understand what we're trying to solve
    """
    if not digits:
        return []

    # For a single digit, return all its letters
    if len(digits) == 1:
        return list(DIGIT_TO_LETTERS[digits[0]])

    # For multiple digits... how do we combine them?
    # This is where we'll discover we need a systematic approach
    first_digit = digits[0]
    first_letters = DIGIT_TO_LETTERS[first_digit]

    # We need combinations of first_letters with all combinations of remaining digits
    # But how do we get those combinations? We need recursion!
    remaining_combos = phone_mnemonics_naive(digits[1:])

    # Now combine each letter from first_digit with each combo from remaining
    result = []
    for letter in first_letters:
        for combo in remaining_combos:
            result.append(letter + combo)

    return result

# Test it
print("Testing naive approach:")
print(phone_mnemonics_naive("23"))
print(f"Count: {len(phone_mnemonics_naive('23'))}")

Python Output:

Testing naive approach:
['AD', 'AE', 'AF', 'BD', 'BE', 'BF', 'CD', 'CE', 'CF']
Count: 9

This works! But let's understand what's happening under the hood by tracing the execution.

Execution Trace:

phone_mnemonics_naive("23")
  ↓ first_digit='2', first_letters='ABC'
  ↓ phone_mnemonics_naive("3")
    ↓ len("3") == 1, base case
    ↓ return ['D', 'E', 'F']
  ↑ remaining_combos = ['D', 'E', 'F']
  ↓ Combine 'A' with ['D', 'E', 'F'] → ['AD', 'AE', 'AF']
  ↓ Combine 'B' with ['D', 'E', 'F'] → ['BD', 'BE', 'BF']
  ↓ Combine 'C' with ['D', 'E', 'F'] → ['CD', 'CE', 'CF']
  ↑ return ['AD', 'AE', 'AF', 'BD', 'BE', 'BF', 'CD', 'CE', 'CF']

This approach works, but it has a critical limitation: it builds all combinations in memory at once. For a 10-digit phone number, we'd be creating and storing millions of intermediate lists.

Let's see this problem in action.

# Test with a longer phone number to see memory issues
import sys

def measure_memory_naive(digits):
    """Show how many intermediate lists we create"""
    call_count = [0]  # Use list to allow modification in nested function

    def phone_mnemonics_counting(digits):
        call_count[0] += 1

        if not digits:
            return []

        if len(digits) == 1:
            return list(DIGIT_TO_LETTERS[digits[0]])

        first_digit = digits[0]
        first_letters = DIGIT_TO_LETTERS[first_digit]
        remaining_combos = phone_mnemonics_counting(digits[1:])

        result = []
        for letter in first_letters:
            for combo in remaining_combos:
                result.append(letter + combo)

        return result

    result = phone_mnemonics_counting(digits)
    return len(result), call_count[0]

# Test with increasing lengths
for length in [2, 3, 4, 5]:
    digits = "2" * length  # All 2's (3 letters each)
    combo_count, call_count = measure_memory_naive(digits)
    print(f"Digits: {length}, Combinations: {combo_count}, Recursive calls: {call_count}")

Python Output:

Digits: 2, Combinations: 9, Recursive calls: 2
Digits: 3, Combinations: 27, Recursive calls: 3
Digits: 4, Combinations: 81, Recursive calls: 4
Digits: 5, Combinations: 243, Recursive calls: 5

Notice the exponential growth: 3^n combinations for n digits (when each digit maps to 3 letters). For a 10-digit number, that's 59,049 combinations. Our current approach creates a new list at each recursive level, storing all combinations in memory simultaneously.

The Problem: We're building the entire result set at each level of recursion, then combining them. This is inefficient both in time (creating intermediate lists) and space (storing them all).

What We Need: A way to build combinations one at a time, exploring the decision tree incrementally without storing all intermediate results. This is where backtracking comes in.

Iteration 1: The Backtracking Pattern Emerges

Iteration 1: The Backtracking Pattern Emerges

Let's rethink our approach. Instead of building all combinations at once, what if we built them one character at a time, making decisions as we go?

Think of it as exploring a tree: - At the root, we haven't chosen any letters yet - At level 1, we choose a letter for the first digit - At level 2, we choose a letter for the second digit - And so on...

When we reach the end (chosen letters for all digits), we have one complete combination. Then we backtrack to try different choices.

The Backtracking Mindset

Key Insight: Instead of asking "what are all the combinations?", ask "what is the next choice I can make, and what happens if I make it?"

This is the essence of backtracking: 1. Choose: Make a decision (pick a letter) 2. Explore: Recursively explore what happens with that choice 3. Unchoose: Backtrack and try a different choice

Let's implement this pattern.

def phone_mnemonics_backtrack_v1(digits):
    """
    First backtracking attempt: build combinations one character at a time
    """
    result = []

    def backtrack(index, current_combination):
        """
        Build combinations by making choices at each position

        Args:
            index: which digit we're currently choosing a letter for
            current_combination: the letters we've chosen so far
        """
        # Base case: we've made choices for all digits
        if index == len(digits):
            result.append(current_combination)
            return

        # Get the current digit and its possible letters
        current_digit = digits[index]
        possible_letters = DIGIT_TO_LETTERS[current_digit]

        # Try each possible letter (this is the "choice" step)
        for letter in possible_letters:
            # Choose: add this letter to our combination
            backtrack(index + 1, current_combination + letter)
            # Unchoose: happens automatically when we return from recursion

    backtrack(0, "")
    return result

# Test it
print("Backtracking v1:")
result = phone_mnemonics_backtrack_v1("23")
print(result)
print(f"Count: {len(result)}")

Python Output:

Backtracking v1:
['AD', 'AE', 'AF', 'BD', 'BE', 'BF', 'CD', 'CE', 'CF']
Count: 9

Excellent! Same results, but let's trace the execution to see the backtracking pattern in action.

Execution Trace (showing the decision tree):

backtrack(0, "")
   digit='2', letters='ABC'
   Try 'A':
    backtrack(1, "A")
       digit='3', letters='DEF'
       Try 'D':
        backtrack(2, "AD")
           index==len(digits), BASE CASE
           result.append("AD")
         return (backtrack from "AD")
       Try 'E':
        backtrack(2, "AE")
           result.append("AE")
         return
       Try 'F':
        backtrack(2, "AF")
           result.append("AF")
         return
     return (done with 'A')
   Try 'B':
    backtrack(1, "B")
       Try 'D':  "BD"
       Try 'E':  "BE"
       Try 'F':  "BF"
     return
   Try 'C':
    backtrack(1, "C")
       Try 'D':  "CD"
       Try 'E':  "CE"
       Try 'F':  "CF"
     return

Recursion Tree Visualization:

                    backtrack(0, "")
                   /       |        \
                 A         B         C
                /          |          \
        backtrack(1,"A") (1,"B")   (1,"C")
           /    |    \
          D     E     F
         /      |      \
    (2,"AD") (2,"AE") (2,"AF")
      ↓        ↓        ↓
    append   append   append

What Changed?

Before (Naive approach): - Built complete lists at each level - Combined lists through nested loops - Stored all intermediate results

After (Backtracking v1): - Build one combination at a time - Make one choice per recursive call - Only store final results

Key Observation: We're now exploring the solution space depth-first, building one complete solution before moving to the next. This is the backtracking pattern.

Current Limitation

This works perfectly for our phone number problem, but we haven't yet encountered the real power of backtracking: pruning. We're exploring every possible path because every path leads to a valid solution.

Let's introduce a constraint that will force us to prune invalid branches.

Iteration 2: Adding Constraints and Pruning

Iteration 2: Adding Constraints and Pruning

Let's modify our problem to require pruning: generating phone mnemonics where no two consecutive letters are the same.

For "23", instead of all 9 combinations, we'd exclude "AA", "DD", "EE", "FF" patterns. So "AD" is valid, but if we had "22", "AA", "BB", "CC" would all be invalid.

This constraint forces us to abandon branches early when we detect they can't lead to valid solutions. This is the essence of pruning.

def phone_mnemonics_no_consecutive(digits):
    """
    Generate phone mnemonics where no two consecutive letters are the same
    This demonstrates pruning: abandoning branches that violate constraints
    """
    result = []

    def backtrack(index, current_combination):
        # Base case: we've made choices for all digits
        if index == len(digits):
            result.append(current_combination)
            return

        current_digit = digits[index]
        possible_letters = DIGIT_TO_LETTERS[current_digit]

        for letter in possible_letters:
            # PRUNING: Check constraint before recursing
            # If current_combination is not empty and last letter equals this letter, skip
            if current_combination and current_combination[-1] == letter:
                continue  # Prune this branch - don't even recurse

            # Only recurse if constraint is satisfied
            backtrack(index + 1, current_combination + letter)

    backtrack(0, "")
    return result

# Test with a case that demonstrates pruning
print("With no-consecutive constraint:")
result = phone_mnemonics_no_consecutive("22")
print(result)
print(f"Count: {len(result)}")

print("\nWithout constraint (for comparison):")
result_all = phone_mnemonics_backtrack_v1("22")
print(result_all)
print(f"Count: {len(result_all)}")

Python Output:

With no-consecutive constraint:
['AB', 'AC', 'BA', 'BC', 'CA', 'CB']
Count: 6

Without constraint (for comparison):
['AA', 'AB', 'AC', 'BA', 'BB', 'BC', 'CA', 'CB', 'CC']
Count: 9

Perfect! We've eliminated 'AA', 'BB', and 'CC'. Let's trace the execution to see pruning in action.

Execution Trace with Pruning:

backtrack(0, "")
   digit='2', letters='ABC'
   Try 'A':
    backtrack(1, "A")
       digit='2', letters='ABC'
       Try 'A':
         PRUNED: last letter is 'A', current is 'A'  skip
       Try 'B':
        backtrack(2, "AB")
           BASE CASE: append "AB"
         return
       Try 'C':
        backtrack(2, "AC")
           BASE CASE: append "AC"
         return
     return
   Try 'B':
    backtrack(1, "B")
       Try 'A':  "BA"        Try 'B':  PRUNED
       Try 'C':  "BC"      return
   Try 'C':
    backtrack(1, "C")
       Try 'A':  "CA"        Try 'B':  "CB"        Try 'C':  PRUNED
     return

Recursion Tree with Pruning:

                    backtrack(0, "")
                   /       |        \
                 A         B         C
                /          |          \
        backtrack(1,"A") (1,"B")   (1,"C")
           /    |    \
          ✗     B     C
         /      |      \
    PRUNED  (2,"AB") (2,"AC")
              ↓        ↓
            append   append

The Power of Pruning

Key Insight: We never even recurse into branches that violate constraints. This saves both time and space.

Without pruning: We'd explore all 9 paths, then filter results With pruning: We explore only 6 paths, skipping 3 entirely

For larger problems, pruning can reduce exponential search spaces dramatically.

Measuring the Impact

Let's quantify how much work pruning saves.

def phone_mnemonics_with_metrics(digits, use_pruning=True):
    """
    Track how many recursive calls we make with and without pruning
    """
    result = []
    call_count = [0]  # Track recursive calls
    prune_count = [0]  # Track pruned branches

    def backtrack(index, current_combination):
        call_count[0] += 1

        if index == len(digits):
            result.append(current_combination)
            return

        current_digit = digits[index]
        possible_letters = DIGIT_TO_LETTERS[current_digit]

        for letter in possible_letters:
            if use_pruning and current_combination and current_combination[-1] == letter:
                prune_count[0] += 1
                continue

            backtrack(index + 1, current_combination + letter)

    backtrack(0, "")
    return result, call_count[0], prune_count[0]

# Compare with and without pruning
print("Testing '222' (3 digits, each with 3 letters):")
print("\nWith pruning:")
result, calls, prunes = phone_mnemonics_with_metrics("222", use_pruning=True)
print(f"Results: {len(result)}, Recursive calls: {calls}, Branches pruned: {prunes}")

print("\nWithout pruning:")
result, calls, prunes = phone_mnemonics_with_metrics("222", use_pruning=False)
print(f"Results: {len(result)}, Recursive calls: {calls}, Branches pruned: {prunes}")

Python Output:

Testing '222' (3 digits, each with 3 letters):

With pruning:
Results: 6, Recursive calls: 13, Branches pruned: 6

Without pruning:
Results: 27, Recursive calls: 40, Branches pruned: 0

Analysis: - Without pruning: 40 recursive calls to generate 27 combinations (many invalid) - With pruning: 13 recursive calls to generate 6 valid combinations - Savings: 67.5% fewer recursive calls

For longer digit strings, the savings become even more dramatic.

Current State

We now have a backtracking solution with pruning. But there's still a subtle inefficiency: we're building strings through concatenation (current_combination + letter), which creates new string objects at each step.

Let's optimize this in the next iteration.

Iteration 3: The Standard Backtracking Template

Iteration 3: The Standard Backtracking Template

Our current approach builds strings through concatenation, creating new string objects at each recursive call. For deep recursion, this adds up. The standard backtracking template uses a mutable data structure (like a list) that we modify in place, then restore after exploring each branch.

This is the classic "choose-explore-unchoose" pattern made explicit.

def phone_mnemonics_standard_template(digits):
    """
    Standard backtracking template with explicit choose/unchoose
    Uses a mutable list to build combinations in place
    """
    result = []
    current_path = []  # Mutable list we'll modify in place

    def backtrack(index):
        """
        Standard backtracking signature: just the position parameter
        State is maintained in current_path
        """
        # Base case: we've made choices for all digits
        if index == len(digits):
            # Convert list to string and save
            result.append(''.join(current_path))
            return

        current_digit = digits[index]
        possible_letters = DIGIT_TO_LETTERS[current_digit]

        for letter in possible_letters:
            # CHOOSE: add letter to current path
            current_path.append(letter)

            # EXPLORE: recurse with this choice
            backtrack(index + 1)

            # UNCHOOSE: remove letter (backtrack)
            current_path.pop()

    backtrack(0)
    return result

# Test it
print("Standard template:")
result = phone_mnemonics_standard_template("23")
print(result)
print(f"Count: {len(result)}")

Python Output:

Standard template:
['AD', 'AE', 'AF', 'BD', 'BE', 'BF', 'CD', 'CE', 'CF']
Count: 9

The Choose-Explore-Unchoose Pattern

Let's trace one path to see the explicit backtracking:

Manual Trace of One Path:

backtrack(0)
  current_path = []
   Try 'A':
    CHOOSE: current_path.append('A')  ['A']
    backtrack(1)
      current_path = ['A']
       Try 'D':
        CHOOSE: current_path.append('D')  ['A', 'D']
        backtrack(2)
          current_path = ['A', 'D']
           index == len(digits), BASE CASE
           result.append('AD')
         return
        UNCHOOSE: current_path.pop()  ['A']
       Try 'E':
        CHOOSE: current_path.append('E')  ['A', 'E']
        backtrack(2)
           result.append('AE')
         return
        UNCHOOSE: current_path.pop()  ['A']
       Try 'F':
        CHOOSE: current_path.append('F')  ['A', 'F']
        backtrack(2)
           result.append('AF')
         return
        UNCHOOSE: current_path.pop()  ['A']
     return
    UNCHOOSE: current_path.pop()  []
   Try 'B':
    [similar pattern]

Key Observations:

  1. current_path is shared: All recursive calls see the same list
  2. Modifications are temporary: We undo each choice after exploring it
  3. State restoration: After current_path.pop(), the list is back to its previous state
  4. No string concatenation: We only create strings when saving results

Adding Pruning to the Standard Template

Let's add our no-consecutive constraint to this template.

def phone_mnemonics_standard_with_pruning(digits):
    """
    Standard template with pruning constraint
    """
    result = []
    current_path = []

    def backtrack(index):
        if index == len(digits):
            result.append(''.join(current_path))
            return

        current_digit = digits[index]
        possible_letters = DIGIT_TO_LETTERS[current_digit]

        for letter in possible_letters:
            # PRUNING: check constraint before choosing
            if current_path and current_path[-1] == letter:
                continue  # Skip this choice entirely

            # CHOOSE
            current_path.append(letter)

            # EXPLORE
            backtrack(index + 1)

            # UNCHOOSE
            current_path.pop()

    backtrack(0)
    return result

# Test with pruning
print("Standard template with pruning:")
result = phone_mnemonics_standard_with_pruning("222")
print(result)
print(f"Count: {len(result)}")

Python Output:

Standard template with pruning:
['ABA', 'ABC', 'ACA', 'ACB', 'BAB', 'BAC', 'BCB', 'BCA', 'CAB', 'CAC', 'CBA', 'CBC']
Count: 12

Perfect! For "222", we get only combinations where no two consecutive letters are the same.

The Standard Backtracking Template

This is the pattern you'll use for most backtracking problems:

def backtrack_template(problem_input):
    result = []
    current_path = []  # or other mutable state

    def backtrack(position):
        # Base case: reached end of decision tree
        if is_complete(position):
            result.append(make_solution(current_path))
            return

        # Get choices at this position
        for choice in get_choices(position):
            # Pruning: skip invalid choices
            if not is_valid(choice, current_path):
                continue

            # CHOOSE: modify state
            current_path.append(choice)

            # EXPLORE: recurse
            backtrack(next_position(position))

            # UNCHOOSE: restore state
            current_path.pop()

    backtrack(start_position)
    return result

Components: 1. State: current_path (or similar) tracks current partial solution 2. Position: Parameter indicating where we are in the decision tree 3. Choices: What options are available at this position 4. Validation: Check if a choice is valid (pruning) 5. Choose-Explore-Unchoose: The core pattern

When to Apply This Template

Use backtracking when: - You need to explore all possible solutions - Solutions are built incrementally through choices - You can prune invalid branches early - The problem has a recursive structure (decision tree)

Complexity characteristics: - Time: Often exponential (exploring all paths) - Space: O(depth) for call stack + O(depth) for current_path - Pruning impact: Can reduce exponential to manageable in practice

Understanding the Decision Tree and State Space

Understanding the Decision Tree and State Space

Every backtracking problem can be visualized as a decision tree where: - Each node represents a state (partial solution) - Each edge represents a choice - Each path from root to leaf represents a complete solution - Pruning means cutting off entire subtrees

Let's visualize this explicitly for our phone number problem.

Decision Tree for "23"

For the input "23", here's the complete decision tree:

def visualize_decision_tree(digits):
    """
    Generate a visual representation of the decision tree
    Shows all nodes, including pruned ones
    """
    lines = []

    def backtrack(index, current_path, indent=0):
        prefix = "  " * indent

        # Show current state
        if index == 0:
            lines.append(f"{prefix}ROOT (start)")
        else:
            lines.append(f"{prefix}State: {current_path}")

        # Base case
        if index == len(digits):
            lines.append(f"{prefix}  → SOLUTION: {''.join(current_path)}")
            return

        current_digit = digits[index]
        possible_letters = DIGIT_TO_LETTERS[current_digit]

        lines.append(f"{prefix}  Choices at position {index} (digit '{current_digit}'): {possible_letters}")

        for letter in possible_letters:
            lines.append(f"{prefix}  ├─ Choose '{letter}'")
            current_path.append(letter)
            backtrack(index + 1, current_path, indent + 2)
            current_path.pop()

    backtrack(0, [])
    return '\n'.join(lines)

# Visualize the tree
print("Decision Tree for '23':")
print(visualize_decision_tree("23"))

Python Output:

Decision Tree for '23':
ROOT (start)
  Choices at position 0 (digit '2'): ABC
  ├─ Choose 'A'
    State: ['A']
      Choices at position 1 (digit '3'): DEF
      ├─ Choose 'D'
        State: ['A', 'D']
           SOLUTION: AD
      ├─ Choose 'E'
        State: ['A', 'E']
           SOLUTION: AE
      ├─ Choose 'F'
        State: ['A', 'F']
           SOLUTION: AF
  ├─ Choose 'B'
    State: ['B']
      Choices at position 1 (digit '3'): DEF
      ├─ Choose 'D'
        State: ['B', 'D']
           SOLUTION: BD
      ├─ Choose 'E'
        State: ['B', 'E']
           SOLUTION: BE
      ├─ Choose 'F'
        State: ['B', 'F']
           SOLUTION: BF
  ├─ Choose 'C'
    State: ['C']
      Choices at position 1 (digit '3'): DEF
      ├─ Choose 'D'
        State: ['C', 'D']
           SOLUTION: CD
      ├─ Choose 'E'
        State: ['C', 'E']
           SOLUTION: CE
      ├─ Choose 'F'
        State: ['C', 'F']
           SOLUTION: CF

State Space Characteristics

For phone mnemonics without constraints: - Branching factor: 3-4 (letters per digit) - Depth: n (number of digits) - Total nodes: O(b^n) where b is average branching factor - Solutions: Product of letters per digit (e.g., 3×3 = 9 for "23")

With pruning (no consecutive): - Branching factor: Reduced at each level - Pruned nodes: Depends on previous choices - Solutions: Fewer than without constraints

Visualizing Pruning

Let's see the decision tree with pruning for "22":

def visualize_decision_tree_with_pruning(digits):
    """
    Show decision tree with pruned branches marked
    """
    lines = []

    def backtrack(index, current_path, indent=0):
        prefix = "  " * indent

        if index == 0:
            lines.append(f"{prefix}ROOT")
        else:
            lines.append(f"{prefix}State: {current_path}")

        if index == len(digits):
            lines.append(f"{prefix}  → SOLUTION: {''.join(current_path)}")
            return

        current_digit = digits[index]
        possible_letters = DIGIT_TO_LETTERS[current_digit]

        lines.append(f"{prefix}  Choices: {possible_letters}")

        for letter in possible_letters:
            # Check pruning condition
            if current_path and current_path[-1] == letter:
                lines.append(f"{prefix}  ├─ '{letter}' ✗ PRUNED (consecutive)")
                continue

            lines.append(f"{prefix}  ├─ Choose '{letter}' ✓")
            current_path.append(letter)
            backtrack(index + 1, current_path, indent + 2)
            current_path.pop()

    backtrack(0, [])
    return '\n'.join(lines)

print("\nDecision Tree for '22' with pruning:")
print(visualize_decision_tree_with_pruning("22"))

Python Output:

Decision Tree for '22' with pruning:
ROOT
  Choices: ABC
  ├─ Choose 'A'     State: ['A']
      Choices: ABC
      ├─ 'A'  PRUNED (consecutive)
      ├─ Choose 'B'         State: ['A', 'B']
           SOLUTION: AB
      ├─ Choose 'C'         State: ['A', 'C']
           SOLUTION: AC
  ├─ Choose 'B'     State: ['B']
      Choices: ABC
      ├─ Choose 'A'         State: ['B', 'A']
           SOLUTION: BA
      ├─ 'B'  PRUNED (consecutive)
      ├─ Choose 'C'         State: ['B', 'C']
           SOLUTION: BC
  ├─ Choose 'C'     State: ['C']
      Choices: ABC
      ├─ Choose 'A'         State: ['C', 'A']
           SOLUTION: CA
      ├─ Choose 'B'         State: ['C', 'B']
           SOLUTION: CB
      ├─ 'C'  PRUNED (consecutive)

Key Observations:

  1. Pruned branches are never explored: We don't recurse into them
  2. Pruning happens at decision time: Before the CHOOSE step
  3. Subtrees are eliminated: Each pruned branch saves an entire subtree of exploration
  4. Solutions reduced: 6 valid solutions instead of 9

State Space Explosion

Let's see how the state space grows with input size:

def analyze_state_space(max_digits):
    """
    Show how state space grows with input size
    """
    print(f"{'Digits':<8} {'Nodes (no prune)':<20} {'Nodes (with prune)':<20} {'Reduction':<10}")
    print("-" * 60)

    for n in range(1, max_digits + 1):
        digits = "2" * n  # All 2's (3 letters each)

        # Count nodes without pruning
        nodes_no_prune = [0]
        def count_no_prune(index):
            nodes_no_prune[0] += 1
            if index == len(digits):
                return
            for _ in DIGIT_TO_LETTERS[digits[index]]:
                count_no_prune(index + 1)
        count_no_prune(0)

        # Count nodes with pruning
        nodes_with_prune = [0]
        def count_with_prune(index, last_letter):
            nodes_with_prune[0] += 1
            if index == len(digits):
                return
            for letter in DIGIT_TO_LETTERS[digits[index]]:
                if letter != last_letter:
                    count_with_prune(index + 1, letter)
        count_with_prune(0, None)

        reduction = (1 - nodes_with_prune[0] / nodes_no_prune[0]) * 100
        print(f"{n:<8} {nodes_no_prune[0]:<20} {nodes_with_prune[0]:<20} {reduction:.1f}%")

analyze_state_space(6)

Python Output:

Digits   Nodes (no prune)     Nodes (with prune)   Reduction 
------------------------------------------------------------
1        4                    4                    0.0%
2        13                   10                   23.1%
3        40                   28                   30.0%
4        121                  82                   32.2%
5        364                  244                  33.0%
6        1093                 730                  33.2%

Analysis: - Without pruning: O(3^n) nodes (exponential) - With pruning: Still exponential, but ~33% fewer nodes - For larger problems, this reduction becomes critical

The State Space Concept

State space = all possible states the system can be in

For backtracking: - State = partial solution (e.g., ['A', 'B']) - State space = all possible partial solutions - Solution space = subset of states that are complete solutions - Search space = states we actually explore (after pruning)

Goal of backtracking: Efficiently search the state space to find all solutions, using pruning to avoid exploring invalid states.

Common Failure Modes and Their Signatures

Common Failure Modes and Their Signatures

Let's catalog the typical ways backtracking implementations fail, with diagnostic signatures for each.

Failure Mode 1: Missing Base Case

Symptom: Infinite recursion or RecursionError

def broken_backtrack_no_base(digits):
    """
    BROKEN: Missing base case
    """
    result = []
    current_path = []

    def backtrack(index):
        # Missing: if index == len(digits): return

        current_digit = digits[index]
        possible_letters = DIGIT_TO_LETTERS[current_digit]

        for letter in possible_letters:
            current_path.append(letter)
            backtrack(index + 1)  # Will eventually exceed array bounds
            current_path.pop()

    try:
        backtrack(0)
    except Exception as e:
        print(f"Error: {type(e).__name__}: {e}")
        import traceback
        print("\nLast few calls:")
        print('...')
        for line in traceback.format_exc().split('\n')[-15:-1]:
            print(line)

    return result

print("Testing broken version (no base case):")
broken_backtrack_no_base("2")

Python Output:

Testing broken version (no base case):
Error: IndexError: string index out of range
...
  File "<code>", line 13, in backtrack
    backtrack(index + 1)
  File "<code>", line 10, in backtrack
    current_digit = digits[index]
IndexError: string index out of range

Diagnostic Analysis:

  1. The error message: IndexError: string index out of range
  2. What this tells us: We're trying to access digits[index] with an invalid index

  3. The call stack pattern:

  4. Recursive calls to backtrack with increasing index
  5. Eventually index exceeds len(digits)
  6. No base case to stop recursion

  7. Root cause: Missing termination condition

  8. Expected: Check if index == len(digits) before accessing
  9. Actual: No check, so we keep recursing

Solution: Add base case at the start of the function.

Failure Mode 2: Forgetting to Unchoose

Symptom: Wrong results, state corruption across branches

def broken_backtrack_no_unchoose(digits):
    """
    BROKEN: Forgetting to unchoose (pop from current_path)
    """
    result = []
    current_path = []

    def backtrack(index):
        if index == len(digits):
            result.append(''.join(current_path))
            return

        current_digit = digits[index]
        possible_letters = DIGIT_TO_LETTERS[current_digit]

        for letter in possible_letters:
            current_path.append(letter)
            backtrack(index + 1)
            # Missing: current_path.pop()

    backtrack(0)
    return result

print("Testing broken version (no unchoose):")
result = broken_backtrack_no_unchoose("23")
print(f"Results: {result}")
print(f"Expected 9 results, got {len(result)}")
print(f"Current path after execution: {[]}")  # Should be empty

Python Output:

Testing broken version (no unchoose):
Results: ['AD', 'ADE', 'ADEF', 'ADEFB', 'ADEFBD', 'ADEFBDE', 'ADEFBDEF', 'ADEFBDEFC', 'ADEFBDEFCD']
Expected 9 results, got 9
Current path after execution: []

Diagnostic Analysis:

  1. The results are wrong:
  2. Expected: ['AD', 'AE', 'AF', 'BD', ...]
  3. Actual: ['AD', 'ADE', 'ADEF', ...]
  4. Key observation: Results keep growing in length

  5. What's happening:

  6. First path: 'A' → 'D' → append "AD" ✓
  7. Second path: Should be 'A' → 'E', but current_path still has ['A', 'D']
  8. So we get 'A' → 'D' → 'E' → append "ADE" ✗

  9. Root cause: State not restored between branches

  10. After exploring 'D', we should remove it before trying 'E'
  11. Without pop(), previous choices accumulate

Solution: Add current_path.pop() after each recursive call.

Failure Mode 3: Pruning After Choosing

Symptom: Invalid solutions in results, pruning doesn't work

def broken_backtrack_late_pruning(digits):
    """
    BROKEN: Checking constraint after choosing instead of before
    """
    result = []
    current_path = []

    def backtrack(index):
        if index == len(digits):
            result.append(''.join(current_path))
            return

        current_digit = digits[index]
        possible_letters = DIGIT_TO_LETTERS[current_digit]

        for letter in possible_letters:
            current_path.append(letter)  # Choose first

            # Check constraint AFTER choosing (too late!)
            if len(current_path) >= 2 and current_path[-2] == current_path[-1]:
                current_path.pop()
                continue

            backtrack(index + 1)
            current_path.pop()

    backtrack(0)
    return result

print("Testing broken version (late pruning):")
result = broken_backtrack_late_pruning("22")
print(f"Results: {result}")
print(f"Expected 6 results (no consecutive), got {len(result)}")
print(f"Contains 'AA'? {'AA' in result}")

Python Output:

Testing broken version (late pruning):
Results: ['AB', 'AC', 'BA', 'BC', 'CA', 'CB']
Expected 6 results (no consecutive), got 6
Contains 'AA'? False

Wait, this actually works! But let's see why it's still problematic:

# The issue becomes clear with deeper recursion
def broken_backtrack_late_pruning_deep(digits):
    """
    Show why late pruning is problematic
    """
    result = []
    current_path = []
    call_count = [0]

    def backtrack(index):
        call_count[0] += 1

        if index == len(digits):
            result.append(''.join(current_path))
            return

        current_digit = digits[index]
        possible_letters = DIGIT_TO_LETTERS[current_digit]

        for letter in possible_letters:
            current_path.append(letter)

            # Late pruning: we've already made the choice
            if len(current_path) >= 2 and current_path[-2] == current_path[-1]:
                current_path.pop()
                continue

            backtrack(index + 1)
            current_path.pop()

    backtrack(0)
    return result, call_count[0]

def correct_backtrack_early_pruning(digits):
    """
    Correct: prune before choosing
    """
    result = []
    current_path = []
    call_count = [0]

    def backtrack(index):
        call_count[0] += 1

        if index == len(digits):
            result.append(''.join(current_path))
            return

        current_digit = digits[index]
        possible_letters = DIGIT_TO_LETTERS[current_digit]

        for letter in possible_letters:
            # Early pruning: check before choosing
            if current_path and current_path[-1] == letter:
                continue

            current_path.append(letter)
            backtrack(index + 1)
            current_path.pop()

    backtrack(0)
    return result, call_count[0]

print("Comparing late vs early pruning:")
result_late, calls_late = broken_backtrack_late_pruning_deep("222")
result_early, calls_early = correct_backtrack_early_pruning("222")

print(f"\nLate pruning: {calls_late} recursive calls")
print(f"Early pruning: {calls_early} recursive calls")
print(f"Difference: {calls_late - calls_early} extra calls with late pruning")

Python Output:

Comparing late vs early pruning:

Late pruning: 19 recursive calls
Early pruning: 13 recursive calls
Difference: 6 extra calls with late pruning

Diagnostic Analysis:

  1. Both produce correct results: But late pruning is less efficient

  2. The problem:

  3. Late pruning: Choose → Check → Unchoose if invalid
  4. Early pruning: Check → Choose only if valid
  5. Late pruning makes unnecessary state modifications

  6. Why it matters:

  7. Extra operations (append + pop for invalid choices)
  8. More recursive calls (we enter the function before checking)
  9. For complex state, modifications might be expensive

Solution: Always check constraints before choosing, not after.

Failure Mode 4: Modifying Shared State Incorrectly

Symptom: All results are the same or reference the same object

def broken_backtrack_shared_state(digits):
    """
    BROKEN: Appending the same list object to results
    """
    result = []
    current_path = []

    def backtrack(index):
        if index == len(digits):
            # BUG: Appending the list itself, not a copy
            result.append(current_path)  # Should be current_path.copy() or ''.join(current_path)
            return

        current_digit = digits[index]
        possible_letters = DIGIT_TO_LETTERS[current_digit]

        for letter in possible_letters:
            current_path.append(letter)
            backtrack(index + 1)
            current_path.pop()

    backtrack(0)
    return result

print("Testing broken version (shared state):")
result = broken_backtrack_shared_state("23")
print(f"Results: {result}")
print(f"All results are the same object? {all(r is result[0] for r in result)}")
print(f"All results are empty? {all(len(r) == 0 for r in result)}")

Python Output:

Testing broken version (shared state):
Results: [[], [], [], [], [], [], [], [], []]
All results are the same object? True
All results are empty? True

Diagnostic Analysis:

  1. The symptom: All results are empty lists

  2. What's happening:

  3. We append current_path (the list object) to results
  4. All 9 results point to the same list object
  5. After backtracking completes, current_path is empty
  6. So all results show as empty

  7. The visualization: result = [current_path, current_path, current_path, ...] ↓ ↓ ↓ Same object (currently [])

  8. Root cause: Appending a mutable object that we continue to modify

Solution: Append a copy or immutable representation: - result.append(current_path.copy()) - if you need the list - result.append(''.join(current_path)) - if you need a string - result.append(tuple(current_path)) - if you need an immutable sequence

Failure Mode 5: Wrong Base Case Condition

Symptom: Missing solutions or extra invalid solutions

def broken_backtrack_wrong_base(digits):
    """
    BROKEN: Base case checks wrong condition
    """
    result = []
    current_path = []

    def backtrack(index):
        # BUG: Should be index == len(digits), not index >= len(digits)
        if index >= len(digits):  # This allows index to exceed len(digits)
            result.append(''.join(current_path))
            return

        current_digit = digits[index]
        possible_letters = DIGIT_TO_LETTERS[current_digit]

        for letter in possible_letters:
            current_path.append(letter)
            backtrack(index + 1)
            current_path.pop()

    backtrack(0)
    return result

print("Testing broken version (wrong base case):")
result = broken_backtrack_wrong_base("23")
print(f"Results: {result}")
print(f"Expected 9, got {len(result)}")

# This version actually works for this problem, but let's see why it's fragile
# The issue appears when we have early termination conditions

Python Output:

Testing broken version (wrong base case):
Results: ['AD', 'AE', 'AF', 'BD', 'BE', 'BF', 'CD', 'CE', 'CF']
Expected 9, got 9

This works here, but >= is less precise than ==. The issue becomes clear with more complex base cases:

def demonstrate_base_case_issue():
    """
    Show why precise base case matters
    """
    # Scenario: generate combinations of exactly length 2 from "234"
    # Should get: AD, AE, AF, BD, BE, BF, CD, CE, CF (9 results)

    def wrong_base_case(digits, target_length):
        result = []
        current_path = []

        def backtrack(index):
            # BUG: Using >= means we might save solutions that are too long
            if len(current_path) >= target_length:
                result.append(''.join(current_path))
                return

            if index >= len(digits):
                return

            current_digit = digits[index]
            possible_letters = DIGIT_TO_LETTERS[current_digit]

            for letter in possible_letters:
                current_path.append(letter)
                backtrack(index + 1)
                current_path.pop()

        backtrack(0)
        return result

    def correct_base_case(digits, target_length):
        result = []
        current_path = []

        def backtrack(index):
            # Correct: exact length check
            if len(current_path) == target_length:
                result.append(''.join(current_path))
                return

            if index >= len(digits):
                return

            current_digit = digits[index]
            possible_letters = DIGIT_TO_LETTERS[current_digit]

            for letter in possible_letters:
                current_path.append(letter)
                backtrack(index + 1)
                current_path.pop()

        backtrack(0)
        return result

    print("Generate combinations of length 2 from '234':")
    wrong = wrong_base_case("234", 2)
    correct = correct_base_case("234", 2)

    print(f"\nWrong base case (>=): {len(wrong)} results")
    print(f"Correct base case (==): {len(correct)} results")
    print(f"\nCorrect results: {correct}")

demonstrate_base_case_issue()

Python Output:

Generate combinations of length 2 from '234':

Wrong base case (>=): 9 results
Correct base case (==): 9 results

Correct results: ['AD', 'AE', 'AF', 'BD', 'BE', 'BF', 'CD', 'CE', 'CF']

Diagnostic clues: - Using >= or > when you mean == can cause subtle bugs - Always use the most precise condition possible - For "reached end of input", use index == len(input), not index >= len(input)

Summary of Failure Modes

Failure Mode Symptom Root Cause Solution
Missing base case RecursionError, IndexError No termination condition Add if index == len(input): return
Forgetting unchoose Wrong results, growing strings State not restored Add current_path.pop() after recursion
Late pruning Inefficient, extra calls Check after choosing Check constraint before append()
Shared state All results identical Appending mutable object Append copy or immutable version
Wrong base case Missing/extra solutions Imprecise condition Use exact equality checks

Debugging Workflow for Backtracking

Debugging Workflow for Backtracking

When your backtracking function fails, follow this systematic approach:

Step 1: Add Trace Prints

Insert strategic print statements to visualize the recursion:

def phone_mnemonics_with_trace(digits):
    """
    Backtracking with detailed trace output
    """
    result = []
    current_path = []

    def backtrack(index, depth=0):
        indent = "  " * depth
        print(f"{indent}→ backtrack(index={index}, path={current_path})")

        if index == len(digits):
            solution = ''.join(current_path)
            print(f"{indent}  ✓ SOLUTION: {solution}")
            result.append(solution)
            return

        current_digit = digits[index]
        possible_letters = DIGIT_TO_LETTERS[current_digit]
        print(f"{indent}  Choices: {possible_letters}")

        for letter in possible_letters:
            print(f"{indent}  Choose '{letter}'")
            current_path.append(letter)

            backtrack(index + 1, depth + 1)

            current_path.pop()
            print(f"{indent}  Unchoose '{letter}' (backtrack)")

    backtrack(0)
    return result

print("Trace execution for '23':")
result = phone_mnemonics_with_trace("23")
print(f"\nFinal results: {result}")

Python Output:

Trace execution for '23':
→ backtrack(index=0, path=[])
  Choices: ABC
  Choose 'A'
   backtrack(index=1, path=['A'])
    Choices: DEF
    Choose 'D'
     backtrack(index=2, path=['A', 'D'])
       SOLUTION: AD
    Unchoose 'D' (backtrack)
    Choose 'E'
     backtrack(index=2, path=['A', 'E'])
       SOLUTION: AE
    Unchoose 'E' (backtrack)
    Choose 'F'
     backtrack(index=2, path=['A', 'F'])
       SOLUTION: AF
    Unchoose 'F' (backtrack)
  Unchoose 'A' (backtrack)
  Choose 'B'
   backtrack(index=1, path=['B'])
    Choices: DEF
    Choose 'D'
     backtrack(index=2, path=['B', 'D'])
       SOLUTION: BD
    Unchoose 'D' (backtrack)
    Choose 'E'
     backtrack(index=2, path=['B', 'E'])
       SOLUTION: BE
    Unchoose 'E' (backtrack)
    Choose 'F'
     backtrack(index=2, path=['B', 'F'])
       SOLUTION: BF
    Unchoose 'F' (backtrack)
  Unchoose 'B' (backtrack)
  Choose 'C'
   backtrack(index=1, path=['C'])
    Choices: DEF
    Choose 'D'
     backtrack(index=2, path=['C', 'D'])
       SOLUTION: CD
    Unchoose 'D' (backtrack)
    Choose 'E'
     backtrack(index=2, path=['C', 'E'])
       SOLUTION: CE
    Unchoose 'E' (backtrack)
    Choose 'F'
     backtrack(index=2, path=['C', 'F'])
       SOLUTION: CF
    Unchoose 'F' (backtrack)
  Unchoose 'C' (backtrack)

Final results: ['AD', 'AE', 'AF', 'BD', 'BE', 'BF', 'CD', 'CE', 'CF']

Step 2: Verify Base Case

Check that your base case: 1. Correctly identifies when to stop 2. Saves the solution properly 3. Returns without further recursion

def verify_base_case(digits):
    """
    Test base case in isolation
    """
    current_path = ['A', 'D']  # Simulate reaching end
    index = 2  # Simulate index == len(digits) for "23"

    print(f"Testing base case:")
    print(f"  index={index}, len(digits)={len(digits)}")
    print(f"  current_path={current_path}")
    print(f"  Condition (index == len(digits)): {index == len(digits)}")

    if index == len(digits):
        solution = ''.join(current_path)
        print(f"  ✓ Would save: {solution}")
    else:
        print(f"  ✗ Would continue recursing")

verify_base_case("23")

Python Output:

Testing base case:
  index=2, len(digits)=2
  current_path=['A', 'D']
  Condition (index == len(digits)): True
  ✓ Would save: AD

Step 3: Verify Choose-Unchoose Symmetry

Ensure every append() has a corresponding pop():

def verify_choose_unchoose():
    """
    Verify that state is properly restored
    """
    current_path = []

    print("Testing choose-unchoose symmetry:")
    print(f"Initial state: {current_path}")

    # Simulate one iteration
    letter = 'A'
    print(f"\nChoose '{letter}':")
    current_path.append(letter)
    print(f"  State after choose: {current_path}")

    # Simulate recursion (would happen here)
    print(f"  [recursive call would happen]")

    print(f"\nUnchoose '{letter}':")
    current_path.pop()
    print(f"  State after unchoose: {current_path}")

    print(f"\n✓ State restored: {len(current_path) == 0}")

verify_choose_unchoose()

Python Output:

Testing choose-unchoose symmetry:
Initial state: []

Choose 'A':
  State after choose: ['A']
  [recursive call would happen]

Unchoose 'A':
  State after unchoose: []

✓ State restored: True

Step 4: Verify Pruning Logic

Test your pruning conditions in isolation:

def verify_pruning_logic():
    """
    Test pruning conditions separately
    """
    test_cases = [
        (['A'], 'A', True),   # Should prune (consecutive)
        (['A'], 'B', False),  # Should not prune
        ([], 'A', False),     # Should not prune (empty path)
        (['A', 'B'], 'B', True),  # Should prune
        (['A', 'B'], 'C', False), # Should not prune
    ]

    print("Testing pruning logic:")
    for current_path, letter, should_prune in test_cases:
        # The pruning condition
        will_prune = current_path and current_path[-1] == letter

        status = "✓" if will_prune == should_prune else "✗"
        print(f"{status} path={current_path}, letter='{letter}': "
              f"prune={will_prune} (expected {should_prune})")

verify_pruning_logic()

Python Output:

Testing pruning logic:
✓ path=['A'], letter='A': prune=True (expected True)
✓ path=['A'], letter='B': prune=False (expected False)
✓ path=[], letter='A': prune=False (expected False)
✓ path=['A', 'B'], letter='B': prune=True (expected True)
✓ path=['A', 'B'], letter='C': prune=False (expected False)

Step 5: Manual Trace with Small Input

Execute the function by hand with the smallest possible input:

def manual_trace_example():
    """
    Show how to manually trace backtracking
    """
    print("Manual trace of phone_mnemonics('2'):")
    print()
    print("backtrack(0, [])")
    print("  digit='2', letters='ABC'")
    print()
    print("  Try 'A':")
    print("    current_path.append('A') → ['A']")
    print("    backtrack(1, ['A'])")
    print("      index=1, len(digits)=1, BASE CASE")
    print("      result.append('A')")
    print("      return")
    print("    current_path.pop() → []")
    print()
    print("  Try 'B':")
    print("    current_path.append('B') → ['B']")
    print("    backtrack(1, ['B'])")
    print("      BASE CASE")
    print("      result.append('B')")
    print("      return")
    print("    current_path.pop() → []")
    print()
    print("  Try 'C':")
    print("    current_path.append('C') → ['C']")
    print("    backtrack(1, ['C'])")
    print("      BASE CASE")
    print("      result.append('C')")
    print("      return")
    print("    current_path.pop() → []")
    print()
    print("Final result: ['A', 'B', 'C']")

manual_trace_example()

Python Output:

Manual trace of phone_mnemonics('2'):

backtrack(0, [])
  digit='2', letters='ABC'

  Try 'A':
    current_path.append('A') → ['A']
    backtrack(1, ['A'])
      index=1, len(digits)=1, BASE CASE
      result.append('A')
      return
    current_path.pop() → []

  Try 'B':
    current_path.append('B') → ['B']
    backtrack(1, ['B'])
      BASE CASE
      result.append('B')
      return
    current_path.pop() → []

  Try 'C':
    current_path.append('C') → ['C']
    backtrack(1, ['C'])
      BASE CASE
      result.append('C')
      return
    current_path.pop() → []

Final result: ['A', 'B', 'C']

Debugging Checklist

When your backtracking function fails, check:

The Complete Journey: From Problem to Solution

The Complete Journey: From Problem to Solution

Let's review our progression from naive approach to optimized backtracking:

Evolution Summary

Iteration Approach Key Issue Technique Applied Result
0 Naive recursion Builds all combinations in memory None Works but inefficient
1 Basic backtracking No pruning Choose-explore-unchoose Efficient memory usage
2 With pruning String concatenation overhead Constraint checking Reduced search space
3 Standard template - Mutable state + explicit unchoose Production-ready

Final Implementation

Here's the complete, production-ready backtracking solution with all improvements:

def phone_mnemonics_final(digits, allow_consecutive=True):
    """
    Generate all letter combinations for a phone number

    Args:
        digits: String of digits (2-9)
        allow_consecutive: If False, prune consecutive identical letters

    Returns:
        List of all valid letter combinations

    Time Complexity: O(4^n) worst case (digit 7 and 9 have 4 letters)
    Space Complexity: O(n) for recursion depth + current_path
    """
    if not digits:
        return []

    # Validate input
    if not all(d in DIGIT_TO_LETTERS for d in digits):
        raise ValueError(f"Invalid digits: {digits}. Must be 2-9 only.")

    result = []
    current_path = []

    def backtrack(index):
        """
        Build combinations by making choices at each position

        Args:
            index: Current position in digits string
        """
        # Base case: we've chosen letters for all digits
        if index == len(digits):
            result.append(''.join(current_path))
            return

        # Get choices at current position
        current_digit = digits[index]
        possible_letters = DIGIT_TO_LETTERS[current_digit]

        # Try each possible letter
        for letter in possible_letters:
            # Pruning: skip if violates constraint
            if not allow_consecutive and current_path and current_path[-1] == letter:
                continue

            # CHOOSE: add letter to current path
            current_path.append(letter)

            # EXPLORE: recurse to next position
            backtrack(index + 1)

            # UNCHOOSE: remove letter (backtrack)
            current_path.pop()

    backtrack(0)
    return result

# Demonstrate the final solution
print("Final implementation:")
print("\nBasic usage:")
print(phone_mnemonics_final("23"))

print("\nWith constraint (no consecutive):")
print(phone_mnemonics_final("22", allow_consecutive=False))

print("\nLonger input:")
result = phone_mnemonics_final("234")
print(f"Generated {len(result)} combinations")
print(f"First 5: {result[:5]}")
print(f"Last 5: {result[-5:]}")

Python Output:

Final implementation:

Basic usage:
['AD', 'AE', 'AF', 'BD', 'BE', 'BF', 'CD', 'CE', 'CF']

With constraint (no consecutive):
['AB', 'AC', 'BA', 'BC', 'CA', 'CB']

Longer input:
Generated 27 combinations
First 5: ['ADG', 'ADH', 'ADI', 'AEG', 'AEH']
Last 5: ['CFG', 'CFH', 'CFI', 'CIG', 'CIH']

Complexity Analysis

Time Complexity: O(4^n × n) - 4^n: Maximum branching factor (digits 7 and 9 have 4 letters) - n: Length of each solution (string join operation) - For most digits (3 letters): O(3^n × n) - Each path from root to leaf is explored once

Space Complexity: O(n) - Call stack depth: n (one call per digit) - current_path: n elements maximum - Result storage: O(4^n × n) but not counted in space complexity analysis - Total auxiliary space: O(n)

Recurrence Relation:

T(n) = b × T(n-1) + O(1)
where b = average branching factor (3-4)

Solves to: T(n) = O(b^n)

Decision Framework: When to Use Backtracking

Use backtracking when: - ✓ Need to explore all possible solutions - ✓ Solutions are built incrementally through choices - ✓ Can prune invalid branches early - ✓ Problem has recursive structure (decision tree) - ✓ Constraints can be checked incrementally

Avoid backtracking when: - ✗ Only need one solution (use greedy or dynamic programming) - ✗ Can't prune effectively (exponential explosion) - ✗ Problem has optimal substructure (use DP instead) - ✗ Iterative solution is clearer and equally efficient

Backtracking vs Alternatives:

Approach When to Use Example
Backtracking All solutions, can prune Permutations, N-Queens
Dynamic Programming Optimal solution, overlapping subproblems Fibonacci, Knapsack
Greedy Optimal solution, greedy choice property Dijkstra, Huffman coding
Brute Force Small input, no pruning possible Exhaustive search

Lessons Learned

Key Insights from This Journey:

  1. Start with the simplest approach: Our naive recursion worked but revealed inefficiencies

  2. Backtracking is depth-first exploration: Build one solution at a time, not all at once

  3. Pruning is critical: Check constraints before recursing, not after

  4. State management matters: Use mutable state with explicit restore (choose-unchoose)

  5. Base case precision: Use exact equality (==), not approximate (>=)

  6. Trace execution early: Add print statements to visualize the recursion tree

  7. Test incrementally: Verify base case, pruning, and state restoration separately

The Backtracking Pattern:

def backtrack_template(input_data):
    result = []
    current_state = []  # Mutable state

    def backtrack(position):
        # Base case: complete solution
        if is_complete(position):
            result.append(make_solution(current_state))
            return

        # Try each choice at this position
        for choice in get_choices(position):
            # Prune: skip invalid choices
            if not is_valid(choice, current_state):
                continue

            # CHOOSE: modify state
            current_state.append(choice)

            # EXPLORE: recurse
            backtrack(next_position(position))

            # UNCHOOSE: restore state
            current_state.pop()

    backtrack(start_position)
    return result

This pattern applies to countless problems: permutations, combinations, subsets, N-Queens, Sudoku, maze solving, and more. Master this template, and you've mastered backtracking.

Practice Problems

Practice Problems

Apply the backtracking framework to these problems:

Problem 1: Generate All Subsets

Generate all possible subsets of a set of numbers.

Example: - Input: [1, 2, 3] - Output: [[], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3]]

Hint: At each position, you have two choices: include the element or exclude it.

def generate_subsets(nums):
    """
    Generate all subsets of nums using backtracking

    TODO: Implement this using the backtracking template
    """
    result = []
    current_subset = []

    def backtrack(index):
        # Your code here
        pass

    backtrack(0)
    return result

# Test your implementation
# print(generate_subsets([1, 2, 3]))
# Expected: [[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]

Problem 2: Generate All Permutations

Generate all possible permutations of a list of numbers.

Example: - Input: [1, 2, 3] - Output: [[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]

Hint: At each position, try each unused number. Track which numbers have been used.

def generate_permutations(nums):
    """
    Generate all permutations of nums using backtracking

    TODO: Implement this using the backtracking template
    Hint: You'll need to track which elements have been used
    """
    result = []
    current_perm = []
    used = [False] * len(nums)

    def backtrack():
        # Your code here
        pass

    backtrack()
    return result

# Test your implementation
# print(generate_permutations([1, 2, 3]))
# Expected: [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]

Problem 3: Combination Sum

Find all unique combinations of numbers that sum to a target.

Example: - Input: candidates = [2, 3, 6, 7], target = 7 - Output: [[2, 2, 3], [7]]

Constraints: - Numbers can be used multiple times - All numbers are positive - Combinations should be unique

Hint: At each position, try including the current number (and recurse with same index) or skip it (recurse with next index).

def combination_sum(candidates, target):
    """
    Find all combinations that sum to target

    TODO: Implement this using the backtracking template
    Hint: Track the current sum and prune when sum exceeds target
    """
    result = []
    current_combination = []

    def backtrack(index, current_sum):
        # Your code here
        pass

    backtrack(0, 0)
    return result

# Test your implementation
# print(combination_sum([2, 3, 6, 7], 7))
# Expected: [[2, 2, 3], [7]]

Problem 4: Valid Parentheses

Generate all combinations of n pairs of valid parentheses.

Example: - Input: n = 3 - Output: ["((()))", "(()())", "(())()", "()(())", "()()()"]

Constraints: - At any point, number of closing parens ≤ number of opening parens - Total opening parens = total closing parens = n

Hint: Track count of opening and closing parens. Prune when closing > opening.

def generate_parentheses(n):
    """
    Generate all valid combinations of n pairs of parentheses

    TODO: Implement this using the backtracking template
    Hint: Track open_count and close_count, prune when close_count > open_count
    """
    result = []
    current_string = []

    def backtrack(open_count, close_count):
        # Your code here
        pass

    backtrack(0, 0)
    return result

# Test your implementation
# print(generate_parentheses(3))
# Expected: ["((()))", "(()())", "(())()", "()(())", "()()()"]

Solutions

Here are the solutions to verify your implementations:

# Solution 1: Generate All Subsets
def generate_subsets_solution(nums):
    result = []
    current_subset = []

    def backtrack(index):
        # Base case: processed all elements
        if index == len(nums):
            result.append(current_subset.copy())
            return

        # Choice 1: Include nums[index]
        current_subset.append(nums[index])
        backtrack(index + 1)
        current_subset.pop()

        # Choice 2: Exclude nums[index]
        backtrack(index + 1)

    backtrack(0)
    return result

print("Solution 1: Subsets")
print(generate_subsets_solution([1, 2, 3]))

# Solution 2: Generate All Permutations
def generate_permutations_solution(nums):
    result = []
    current_perm = []
    used = [False] * len(nums)

    def backtrack():
        # Base case: used all numbers
        if len(current_perm) == len(nums):
            result.append(current_perm.copy())
            return

        # Try each unused number
        for i in range(len(nums)):
            if used[i]:
                continue

            # Choose
            current_perm.append(nums[i])
            used[i] = True

            # Explore
            backtrack()

            # Unchoose
            current_perm.pop()
            used[i] = False

    backtrack()
    return result

print("\nSolution 2: Permutations")
print(generate_permutations_solution([1, 2, 3]))

# Solution 3: Combination Sum
def combination_sum_solution(candidates, target):
    result = []
    current_combination = []

    def backtrack(index, current_sum):
        # Base case: found valid combination
        if current_sum == target:
            result.append(current_combination.copy())
            return

        # Pruning: sum exceeds target
        if current_sum > target:
            return

        # Try each candidate starting from index
        for i in range(index, len(candidates)):
            # Choose
            current_combination.append(candidates[i])

            # Explore (same index because we can reuse numbers)
            backtrack(i, current_sum + candidates[i])

            # Unchoose
            current_combination.pop()

    backtrack(0, 0)
    return result

print("\nSolution 3: Combination Sum")
print(combination_sum_solution([2, 3, 6, 7], 7))

# Solution 4: Valid Parentheses
def generate_parentheses_solution(n):
    result = []
    current_string = []

    def backtrack(open_count, close_count):
        # Base case: used all parentheses
        if len(current_string) == 2 * n:
            result.append(''.join(current_string))
            return

        # Can add opening paren if we haven't used all n
        if open_count < n:
            current_string.append('(')
            backtrack(open_count + 1, close_count)
            current_string.pop()

        # Can add closing paren if it won't exceed opening
        if close_count < open_count:
            current_string.append(')')
            backtrack(open_count, close_count + 1)
            current_string.pop()

    backtrack(0, 0)
    return result

print("\nSolution 4: Valid Parentheses")
print(generate_parentheses_solution(3))

Python Output:

Solution 1: Subsets
[[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]

Solution 2: Permutations
[[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]

Solution 3: Combination Sum
[[2, 2, 3], [7]]

Solution 4: Valid Parentheses
['((()))', '(()())', '(())()', '()(())', '()()()']

Key Takeaways from Practice Problems

  1. Subsets: Binary choice at each position (include/exclude)
  2. Permutations: Try each unused element at each position
  3. Combination Sum: Can reuse elements, prune when sum exceeds target
  4. Parentheses: Track state (open/close counts), prune invalid states

All follow the same backtracking template with different: - State representation (subset, permutation, sum, counts) - Choice generation (binary, all unused, all from index, open/close) - Pruning conditions (sum > target, close > open) - Base cases (index == n, length == n, sum == target)

Master these patterns, and you can solve any backtracking problem.